跳到正文

用 MCP 搭一套 Agent 工具链:踩过的坑和最终架构

从最小可用的工具调用循环开始,讲清工具描述怎么写、上下文怎么管、多步任务为什么会跑飞,以及我最后收敛到的架构。

1179 字约 3 分钟直达下载 ↓
本文目录(6)

MCP(Model Context Protocol)解决的是一个很实际的问题:别让每个 Agent 框架都自己实现一遍工具接入。

在它出现之前,我接一个「读数据库」的能力,要分别为 LangChain、自研框架、Claude 客户端各写一遍适配。有了 MCP,写一个 server,所有支持它的客户端都能用。

但真正把 Agent 跑稳,MCP 只是其中一块。这篇记录我踩过的坑。

最小可用的工具调用循环

抛开框架,Agent 的本质就是一个 while 循环:

def run_agent(user_input, tools, max_steps=10):
    messages = [{"role": "user", "content": user_input}]

    for step in range(max_steps):
        response = llm.chat(messages, tools=tools)

        # 没有工具调用 → 任务结束
        if not response.tool_calls:
            return response.content

        messages.append(response.message)

        for call in response.tool_calls:
            try:
                result = execute(call.name, call.arguments)
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": str(result)[:4000],   # 一定要截断
                })
            except Exception as e:
                # 关键:把错误也喂回去,而不是直接抛出
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": f"工具执行失败:{e}",
                })

    return "达到最大步数限制,任务未完成"

这三十行里有两个地方最容易写错:

错误必须喂回模型。 直接抛异常终止,模型就没机会换一种方式重试。把 "工具执行失败:文件不存在" 作为工具结果返回,模型通常会自己改用正确的路径。

工具结果必须截断。 一次返回 50 KB 的 JSON,上下文直接爆掉,而且这部分内容 95% 是噪音。

Agent 工具调用循环:思考、调用、观察、再思考
Agent 工具调用循环:思考、调用、观察、再思考

工具描述怎么写,决定了 Agent 稳不稳

这是我投入产出比最高的一处改动。工具描述不是写给人看的文档,是写给模型看的接口说明。

对比一下:

// 差:模型不知道什么时候该用、参数该给什么
{
  "name": "query_db",
  "description": "查询数据库",
  "parameters": { "sql": { "type": "string" } }
}

// 好:明确了用途边界、何时不该用、参数格式和限制
{
  "name": "query_db",
  "description": "对只读的 PostgreSQL 数据库执行 SELECT 查询。仅用于读取数据,写入操作请使用 write_db。单次查询超时 10 秒,返回最多 100 行,超过会被截断。",
  "parameters": {
    "sql": {
      "type": "string",
      "description": "标准 PostgreSQL SELECT 语句。不要加分号,不要使用 WITH RECURSIVE。"
    }
  }
}

三件事必须写清楚:

  1. 什么时候不该用 —— 这是收益最大的一条。模型幻觉调用工具,多半是因为描述里没有边界
  2. 失败会怎样 —— 超时多久、截断到多少行,让模型对返回结果有预期
  3. 参数字面要求 —— 「不要加分号」这种细节,能消掉一大批解析错误

上下文管理:Agent 会自己把自己撑死

多步任务里,每轮的工具返回都会进上下文。跑 10 步之后,历史里塞满了原始数据,模型开始忘记最初的目标。

我试过三种办法:

办法效果代价
只保留最近 N 轮简单有效丢失早期关键信息
每轮结束生成摘要效果好多一次模型调用,延迟增加
把中间结果存外部,上下文只留引用最省上下文实现复杂

我最后用的是混合方案:工具返回先在代码里做结构化压缩(只留关键字段),累积到一定量之后再让模型生成一次摘要。

def compress_tool_result(name, raw):
    """在进入上下文之前先压缩,而不是指望模型自己忽略噪音"""
    if name == "query_db":
        rows = raw[:100]
        return {
            "columns": raw.columns,
            "row_count": len(raw),
            "preview": rows,
            "note": "已截断,完整结果已保存到 /tmp/result.csv" if len(raw) > 100 else None,
        }
    return str(raw)[:4000]

把完整结果落盘、上下文里只留路径和预览,是我觉得最实用的一招。需要细节时模型可以再调一次工具去读文件。

多步任务为什么会跑飞

三个典型失败模式,和对策:

任务漂移。 模型在第 6 步开始做一件和原始目标无关的事。对策是在每轮循环开头注入一句系统提醒:当前目标:{原始请求}。已完成:{步骤摘要}。

死循环。 反复用同样的参数调同一个工具。对策是记录调用指纹(工具名 + 参数哈希),重复超过 2 次就直接返回错误提示模型换方案。

过早收尾。 才执行了一步就声称任务完成。对策是把「完成条件」显式写进提示词,并要求模型在结束前自检一遍。

# 死循环检测,很简单但极其有效
seen = {}
fingerprint = f"{call.name}:{hash(json.dumps(call.arguments, sort_keys=True))}"
seen[fingerprint] = seen.get(fingerprint, 0) + 1
if seen[fingerprint] > 2:
    return "你已经用相同参数调用过这个工具两次,请换一种方式或给出结论。"

我最终收敛的架构

用户请求
   ↓
规划层(拆解成步骤,可选,简单任务跳过)
   ↓
执行循环 ←──────────────┐
   ├─ 选工具            │
   ├─ 参数校验          │
   ├─ 执行(带超时)     │
   ├─ 结果压缩          │
   ├─ 死循环检测        │
   └─ 状态更新 ─────────┘
   ↓
完成条件校验 → 通过 → 返回结果
              ↓ 不通过
            带反馈重新执行

核心设计取舍:把确定性的事情放在代码里,把需要判断的事情交给模型。

超时、重试、去重、截断、状态机 —— 这些都是代码该干的。模型负责的是「下一步做什么」和「拿到结果后怎么解读」。

我见过太多 Agent 项目把重试逻辑也塞进提示词让模型自己决定,结果就是不稳定、还贵。

小结

MCP 让工具接入标准化了,但 Agent 稳不稳,主要看这几件事:工具描述写得够不够清楚、上下文有没有主动管理、失败路径有没有设计。

示例 server、主循环代码和一批工具描述范例都在下载区,可以直接拿去改。

资源下载

2 个入口

链接若失效,欢迎发邮件告诉我,我会尽快重新上传。